<?php    
    abstract class Model {
        const IDCOL = "id";
        
        private $_data = array();
                
        protected $_childClass      = array();
        protected $_childs          = array();
        protected $_foreignIdCol    = null;
        protected $_hashIdCol       = "hashid";
        protected $_table           = null;
        protected $_userIdCol       = "userid";
        
        protected static $_schema = array();
        
        public static $currentTimeZone  = null;
        public static $currentUserId    = null;
        
        public function __construct($id = null) {
            # Hae taulun rakenne
            if (!isset(self::$_schema[$this->_table])) {
                $this->_fetchSchema();
            }
            
            # Asetetaan oletusarvot
            foreach (self::$_schema[$this->_table] as $key => $data) {
                $this->_data[$key] = $data["default"];
            }    
            
            # Id on taulukko
            if (is_array($id)) {
                if (array_key_exists(self::IDCOL,$id)) {
                    $id = $id[self::IDCOL];
                } else if (array_key_exists($this->_hashIdCol,$id)) {
                    $id = $this->_getByHashid($id[$this->_hashIdCol]);
                    if (!$id) {
                        throw new DBException(
                            "Unknown hashid",
                            30,
                            get_class($this),
                            "Unknown hashid"
                        );
                    }
                } else {
                    $id = null;
                }
            }
            
            # Alusta data
            if (isset($id)) {
                $this->{self::IDCOL} = $id;
                $this->load();                                
            }
        }
        
        public function __get($key) {
            if (array_key_exists($key,$this->_childs)) {
                return $this->_childs[$key];
            } else {            
                if (!array_key_exists($key,$this->_data)) {
                    throw new Exception("Unknown key $key");
                }
                
                $value = $this->_data[$key];
                
                # Onko arvon konvertointia
                $schema = self::$_schema[$this->_table][$key];
                if (isset($schema["getConvert"]) && isset($value)) {
                    switch ($schema["getConvert"]) {
                        case "timezone": # Muuta aikavyöhyke
                            if (isset(static::$currentTimeZone)) {
                                $value = $this->_convertTimeZone(
                                    $value,
                                    config("API","timezone"),
                                    static::$currentTimeZone
                                );
                            }
                            break;
                    }
                }
                
                return $value;
            }
        }
        
        public function __isset($key) {
            if (array_key_exists($key,$this->_childs)) {
                return isset($this->_childs[$key]);
            } else {
                return isset($this->_data[$key]);
            }
        }
        
        public function __set($key,$value) {
            if (array_key_exists($key,$this->_childs)) {
                # Tarkastetaan onko objekti oikeasta luokasta
                if (isset($this->_childClass[$key])) {
                    if (!($value instanceof $this->_childClass[$key])) {
                        throw new ValidationException(                        
                            "Invalid child",
                            0,
                            get_class($this),
                            $key,
                            $value                        
                        );    
                    }
                }
                
                if (!is_array($this->_childs[$key])) {
                    $this->_childs[$key] = array();    
                }
                
                $this->_childs[$key][] = $value;
            } else {
                $value = $this->_validate($key,$value);
                
                # Onko arvon konvertointia
                $schema = self::$_schema[$this->_table][$key];
                if (isset($schema["setConvert"]) && isset($value)) {
                    switch ($schema["setConvert"]) {
                        case "timezone": # Muuta aikavyöhyke
                            if (isset(static::$currentTimeZone)) {
                                $value = $this->_convertTimeZone(
                                    $value,
                                    static::$currentTimeZone,
                                    config("API","timezone")
                                );
                            }
                            break;
                    }
                }
                
                $this->_data[$key] = $value;
            }
        }
        
        public function __unset($key) {
            if (array_key_exists($key,$this->_childs)) {
                $this->_childs[$key] = null;
            } else {
                if (!array_key_exists($key,$this->_data)) {
                    throw new Exception("Unknown key $key");
                }
                $this->_data[$key] = null;
            }
        }
        
        /**
         * Funktio suorittaa halutun funktiokutsun alitietueilla.
         */
        private function _callChilds($method) {
            foreach ($this->_childs as $childs) {
                if (is_array($childs)) {
                    foreach ($childs as $child) {
                        if (is_subclass_of($child,"Model")) {
                            $child->{$method}();
                        }
                    }
                }    
            }    
        }
        
        /**
         * Funktio vaihtaa aikaleiman aikahyöhykkeen toiseksi ja palauttaa ajan
         * halutun aikavyöhykkeen aikana (Y-m-d H:i:s).
         *
         * @param   string  $datetime       Aikaleima
         * @param   string  $fromTimeZone   Aikaleiman aikavyöhyke
         * @param   string  $toTimeZone     Haluttu aikahyöhyke
         * @return  string
         */
        private function _convertTimeZone($datetime,$fromTimeZone,$toTimeZone) {
            $converted = $datetime;
            
            $date = date_create($converted,timezone_open($fromTimeZone));
            if ($date) {
                $date->setTimezone(new DateTimeZone($toTimeZone));
                $converted = $date->format("Y-m-d H:i:s");
            }
            
            return $converted;
        }
        
        /**
         * Funktio hakee taulun rakenteen tietokannasta ja asettaa sen
         * staattiseen _schema - muuttujaan.
         */
        private function _fetchSchema() {            
            # Haeteaan taulun sarakkeet
            $show = DB::select("SHOW COLUMNS FROM {$this->_table}");
            
            # Taulu on tyhjä
            if (empty($show)) {
                throw new DBException(
                    "Table structure is empty",
                    0,
                    get_class($this),
                    "Database initialization failed"
                );
            }
            
            # Käydään sarakkeet läpi
            foreach ($show as $row) {
                $regs = array();
                mb_eregi("^([A-Za-z0-9\$_]{1,})(\((([0-9]{1,}(,[0-9]{1,})?)|('[\w\-_]{1,}',?)+)\))?( unsigned)?$",
                    $row["Type"],
                    $regs
                );
                                
                # Tuntematon tietotyyppi
                if (empty($regs)) {
                    throw new DBException(
                        "Unrecognized column datatype {$row["Type"]}",
                        0,
                        get_class($this),
                        "Database initialization failed"
                    );
                }
                
                $allowNull      = (($row["Null"] == "YES") ? true : false);
                $default        = $row["Default"];
                $getConvert     = null;
                $inArray        = null;
                $match          = null;
                $max            = null;
                $maxlength      = null;
                $minlength      = null;
                $min            = null;
                $setConvert     = null;
                $trim           = true;
                $type           = null;
                $validator      = null;
                $write          = true;
                
                switch ($regs[1]) {
                    # Kokonaisluvut
                    case "tinyint":
                    case "smallint":
                    case "int":
                        $match = "^\-?[0-9]{1,}$";
                        $type = array("integer","string");
                        switch ($regs[1]) {
                            case "tinyint":
                                if ($regs[7]) {
                                    $max = 255;
                                    $min = 0;
                                } else {
                                    $max = 127;
                                    $min = -128;
                                }
                                break;
                            
                            case "smallint":
                                if ($regs[7]) {
                                    $max = 65535;
                                    $min = 0;
                                } else {
                                    $max = 32767;
                                    $min = -32768;
                                }
                                break;
                            
                            case "int":
                                if ($regs[7]) {
                                    $max = 4294967295;
                                    $min = 0;
                                } else {
                                    $max = 2147483647;
                                    $min = -2147483648;
                                }
                                break;
                        }
                        break;
                    
                    # Desimaaliluvut
                    case "decimal":
                        # Laske desimaaliluvun maksipituus
                        $tmp = explode(",",$regs[3]);
                        $numbers = $tmp[0]-$tmp[1];
                        $decimals = $tmp[1];
                        
                        $match = "^";
                        if (!$regs[7]) {
                            $match .= "\-?";
                        }                        
                        $match .= "[0-9]{1,$numbers}(\.[0-9]{1,$decimals})?$";
                        
                        $type = array("double","integer","string");
                        break;
                    
                    # Merkkijonot
                    case "char":
                    case "varchar":
                        $minlength = 0;
                        $maxlength = $regs[3];
                        $type = array("double","integer","string");
                        break;
                    
                    # Tekstikentät:
                    case "text":
                        $type = array("double","integer","string");
                        break;
                    
                    # Enum
                    case "enum":
                        $inArray = array();
                        foreach (explode(",",$regs[3]) as $allowed) {
                            $inArray[] = mb_substr($allowed,1,-1);
                        }                        
                        $type = array("double","integer","string");
                        break;
                    
                    # Päivämäärät
                    case "date":
                        $match = "^[\d]{4}\-[\d]{2}\-[\d]{2}$";
                        $type = array("string");
                        break;
                    
                    # Aika
                    case "datetime":
                    case "timestamp":
                        $match = "^[\d]{4}\-[\d]{2}\-[\d]{2}( [\d]{2}:[\d]{2}(:[\d]{2})?)?$";
                        $type = array("string");
                        $setConvert = $getConvert = "timezone";
                        if ($regs[1] == "timestamp" && $default == "CURRENT_TIMESTAMP") {
                            $default = null;
                            $write = false;
                        }
                        break;
                    
                    default:
                        throw new DBException(
                            "Unsupported column datatype {$row["Type"]}",
                            0,
                            get_class($this),
                            "Database initialization failed"
                        );
                }
                
                # Päättele validointifunktio ja muut nimikohtaiset oletukset
                switch ($row["Field"]) {
                    case "email": # Sähköposti
                        $validator = "email";
                        break;
                    
                    case self::IDCOL:
                        # Pääavainta ei kirjoiteta tietokantaan
                        $write = false;
                        break;
                    
                    case $this->_hashIdCol:
                        # Hashid
                        if (isset($this->_hashIdCol)) {
                            $match = "^[a-fA-F0-9]{33,40}$";
                        }
                        break;
                }
                
                self::$_schema[$this->_table][$row["Field"]] = array(
                    "allowNull"     => $allowNull,
                    "default"       => $default,
                    "getConvert"    => $getConvert,
                    "inArray"       => $inArray,
                    "match"         => $match,
                    "max"           => $max,
                    "maxlength"     => $maxlength,
                    "min"           => $min,
                    "minlength"     => $minlength,
                    "setConvert"    => $setConvert,
                    "trim"          => $trim,
                    "type"          => $type,
                    "validator"     => $validator,
                    "write"         => $write
                );
            }
        }
        
        /**
         * Hakee tietokannasta tietueen id:n hashid:n perusteella.
         *
         * Jos uniikkia tietuetta ei löydy palautetaan arvo nolla.
         *
         * @param   string  $hashid     Hashid
         * @return  int
         */
        protected function _getByHashId($hashid) {
            $result = 0;
            
            $hashIdCol = $this->_hashIdCol;
            if (!isset($hashIdCol)) {
                return $result;
            }
            
            if (array_key_exists($hashIdCol,$this->_data) && isset($hashid)) {
                $idField = self::IDCOL;
                $query = DB::preparedQuery("
                    SELECT {$idField} 
                    FROM {$this->_table}
                    WHERE {$hashIdCol}=:hashid
                    ",array(
                        "hashid" => $hashid
                    )
                );
                if (DB::numRows($query) == 1) {
                    $result = DB::result($query,0,$idField);
                }
                DB::freeResult($query);
            }
            
            return $result;
        }
        
        /**
         * Funktio validoi sarakkeeseen vietävän datan tietokantarakennetta vastaan.
         *
         * Virhetilanteessa nostetaan keskeytys.
         *
         * Funktio palauttaa sarakkeeseen vietävän arvon mahdollisten konversioiden jälkeen.
         *
         * @return  mixed
         */
        protected function _validate($key,$value) {
            if (!array_key_exists($key,self::$_schema[$this->_table])) {
                throw new ValidationException(                        
                    "Unknown key",
                    1,
                    get_class($this),
                    $key,
                    $value                        
                );
            }
            
            $schema = self::$_schema[$this->_table][$key];
            
            # Arvo on taulukko, löytyykö sarake sieltä
            if (is_array($value)) {                
                if (array_key_exists($key,$value)) {
                    # Löytyy, poimitaan
                    $value = $value[$key];
                } else {
                    # Ei löydy, asetetaan oletus
                    $value = $schema["default"];
                }
            }
            
            # Trimmataanko arvo
            if ($schema["trim"] && is_string($value)) {
                $value = trim($value);
            }
            
            # Sallitaanko null arvot
            if (!$schema["allowNull"] && !isset($value)) {
                throw new ValidationException(                        
                    "Value can't be null",
                    10,
                    get_class($this),
                    $key,
                    $value                        
                );
            } else if ($schema["allowNull"] && !isset($value)) {
                # Null arvot sallitaan -> nyt voidaan tehdä palautus
                return $value;
            }
            
            # Onko tietotyyppi oikea
            if (is_array($schema["type"]) && !empty($schema["type"])) {
                $gettype = gettype($value);            
                if (!in_array($gettype,$schema["type"])) {
                    throw new ValidationException(                        
                        "Invalid datatype $gettype",
                        20,
                        get_class($this),
                        $key,
                        $value                        
                    );
                }
            }
            
            # Vastaako arvo säännöllistä lauseketta
            if (isset($schema["match"])) {
                if (!mb_ereg_match($schema["match"],$value)) {
                    throw new ValidationException(                        
                        "Value is invalid",
                        30,
                        get_class($this),
                        $key,
                        $value                        
                    );
                }
            }
            
            # Onko arvo suurempi kuin sallittu maksimi
            if (isset($schema["max"]) && $value > $schema["max"]) {
                throw new ValidationException(                        
                    "Value is larger than allowed maximum value ({$schema["max"]})",
                    40,
                    get_class($this),
                    $key,
                    $value                        
                );
            }
            
            # Onko arvo pienempi kuin sallittu minimi
            if (isset($schema["min"]) && $value < $schema["min"]) {
                throw new ValidationException(                        
                    "Value is smaller than required minimum value ({$schema["min"]})",
                    50,
                    get_class($this),
                    $key,
                    $value                        
                );
            }
            
            # Onko arvon pituus suurempi kuin sallittu
            if (isset($schema["maxlength"]) && mb_strlen($value) > $schema["maxlength"]) {
                throw new ValidationException(                        
                    "Value is longer than allowed maximum length ({$schema["maxlength"]})",
                    60,
                    get_class($this),
                    $key,
                    $value                        
                );
            }
            
            # Onko arvon pituus pienempi kuin sallittu
            if (isset($schema["minlength"]) && mb_strlen($value) < $schema["minlength"]) {
                throw new ValidationException(                        
                    "Value is shorter than required minimum length ({$schema["minlength"]})",
                    70,
                    get_class($this),
                    $key,
                    $value                        
                );
            }
            
            # Onko arvo sallittujen arvojen sisällä
            if (is_array($schema["inArray"]) && !empty($schema["inArray"])) {
                if (!in_array($value,$schema["inArray"])) {
                    throw new ValidationException(
                        "Value is not in allowed range",
                        80,
                        get_class($this),
                        $key,
                        $value                        
                    );
                }
            }
            
            # Onko validointifunktio asetettu
            if (isset($schema["validator"])) {
                if (is_string($schema["validator"])) {
                    switch ($schema["validator"]) {
                        case "email": # Tarkasta sähköposti
                            if (!filter_var($value,FILTER_VALIDATE_EMAIL)) {
                                throw new ValidationException(
                                    "Invalid email",
                                    90,
                                    get_class($this),
                                    $key,
                                    $value                        
                                );    
                            }
                            break;
                    }
                } else if ($schema["validator"] instanceof Closure) {
                    # Anonyymi funktio
                    $schema["validator"]($key,$value,$this->_data);
                }
            }
            
            return $value;
        }
        
        /**
         * Funktio tarkastaa onko käyttäjällä käyttöoikeutta dataan.
         *
         * Mikäli käyttöoikeutta ei ole nostetaan keskeytys.
         *
         * @return  bool
         */
        public function checkAccess() {
            $userIdCol = $this->_userIdCol;
            if (isset($userIdCol) && isset($this->{$userIdCol})) {
                # Ei käyttöoikeutta
                if ($this->{$userIdCol} != static::$currentUserId) {
                    throw new APIException("Access denied",40,get_class($this));
                }
            }
            
            return true;
        }
        
        /**
         * Funktio poistaa tietokannasta tietueen sekä alitietueet ja asettaa id:n oletusarvoon.
         */
        public function delete() {
            $idCol = self::IDCOL;
            $id = $this->{$idCol};
            if (isset($id)) {
                $this->deleteChilds();
                
                $query = DB::preparedQuery("DELETE FROM {$this->_table} WHERE {$idCol}=:id",array(
                    "id" => $id
                ));
                
                if (DB::errno() != 0 || DB::affectedRows() == 0) {
                    throw new DBException(
                        "Failed to delete the record",
                        40,
                        get_class($this),
                        "Failed to delete the record"
                    );                    
                }
                
                $this->_data[$idCol] = self::$_schema[$this->_table][$idCol]["default"];
            }
        }
        
        /**
         * Funktio poistaa tietokannasta tietueen alitietueet.
         */
        public function deleteChilds() {
            $this->_callChilds("delete");
        }
        
        /**
         * Palauttaa foreignIdCol - muuttujan joka kertoo mikä on tietueen 
         * vierasavain alitietueissa.
         */
        public function getForeignIdCol() {
            return $this->_foreignIdCol;
        }
        
        /**
         * Palauttaa taulun nimen josta tietueen data on peräisin.
         */
        public function getTable() {
            return $this->_table;
        }
        
        /**
         * Palauttaa _userIdCol - muuttujan joka kertoo mihin sarakkeeseen
         * tallennetaan taulussa käyttäjän id.
         */
        public function getUserIdCol() {
            return $this->_userIdCol;
        }
        
        /**
         * Funktio lataa tietokannasta tietueen ja alituiteiden datan.
         */
        public function load() {            
            $idCol = self::IDCOL;
            $id = $this->{$idCol};
            
            $query = DB::preparedQuery("SELECT * FROM {$this->_table} WHERE {$idCol}=:id LIMIT 1",array(
                "id" => $id
            ));
            
            if (DB::numRows($query) == 0) {
                throw new DBException(
                    "Can't find the record",
                    30,
                    get_class($this),
                    "Can't find the record"
                );
            }
            
            foreach (DB::fetchAssoc($query) as $key => $value) {
                $this->_data[$key] = $value;
            }
            
            DB::freeResult($query);
            
            # Tarkasta käyttäoikeus
            $this->checkAccess();
                        
            # Lataa mahdolliset alitietueet
            foreach ($this->_childs as $key => $childs) {
                if (!isset($this->_childClass[$key])) {
                    continue;
                }
                
                unset($this->{$key});                                
                
                $query = DB::preparedQuery("
                    SELECT $idCol
                    FROM $key
                    WHERE {$this->_foreignIdCol}=:id
                    ORDER BY $idCol
                    ",array(
                    "id" => $id
                ));
                if (DB::numRows($query) > 0) {
                    while ($row = DB::fetchAssoc($query)) {
                        $this->{$key} = new $this->_childClass[$key]($row[$idCol]);
                    }
                    DB::freeResult($query);
                }
            }
        }
        
        /**
         * Funktio ajaa unset funktio alitietuille.
         */
        public function unsetChilds() {
            foreach ($this->_childs as $child => $childs) {
                unset($this->{$child});
            }
        }
        
        /**
         * Päivittää vain halutut sarakkeet tietueesta tietokantaan.
         *
         * Funktio palauttaa arvon yksi jos yksikin sarake muuttui, jos data ei
         * muuttunut palautetaan arvo nolla.
         *
         * @param   array   $data   Sarakkeet ja niiden data jotka päivitetään
         * @return  int
         */
        public function update($data) {
            $result = 0;
            $id = $this->_data[self::IDCOL];
            
            if (isset($id) && is_array($data)) {
                # Validoi data
                foreach ($data as $key => $value) {
                    # Arvoa ei viedä tietokantaan, esim. timestamp/CURRENT_TIMESTAMP
                    if (!self::$_schema[$this->_table][$key]["write"]) {
                        unset($data[$key]);
                        continue;
                    }
                    $this->{$key} = $value;
                }
                
                if (!empty($data)) {
                    $result = DB::update($this->_table,$data,array(
                        self::IDCOL => $id
                    ));
                    
                    if (DB::errno() != 0) {
                        throw new DBException(
                            "Failed to update the record",
                            20,
                            get_class($this),
                            "Failed to update the record"
                        );
                    }
                }
            }
            
            return $result;
        }
        
        /**
         * Funktio kirjoittaa olion ja alitietueiden datan tietokantaan
         * ja palauttaa tietueen pääavaimen arvon.
         *
         * @return  int
         */
        public function write() {
            $data = $this->_data;
            $id = $data[self::IDCOL];
            
            # Validoi data
            foreach ($data as $key => $value) {
                # Arvoa ei viedä tietokantaan, esim. timestamp/CURRENT_TIMESTAMP
                if (!self::$_schema[$this->_table][$key]["write"]) {
                    unset($data[$key]);
                    continue;
                }
                $this->_validate($key,$value);
            }
                        
            if (!isset($id)) {                
                $insertid = DB::insert($this->_table,$data);
                
                if (DB::errno() != 0 || $insertid == 0) {
                    throw new DBException(
                        "Failed to create a new record",
                        10,
                        get_class($this),
                        "Failed to create a new record"
                    );                    
                }
                
                $result = $this->_data[self::IDCOL] = $insertid;
            } else {
                DB::update($this->_table,$data,array(
                    self::IDCOL => $id
                ));
                
                if (DB::errno() != 0) {
                    throw new DBException(
                        "Failed to update the record",
                        20,
                        get_class($this),
                        "Failed to update the record"
                    );
                }
                
                $result = $id;
            }
            
            # Muodosta hashid tarvittaessa
            $hashIdCol = $this->_hashIdCol;
            if (isset($hashIdCol)) {                
                if (array_key_exists($hashIdCol,$data) && !$data[$hashIdCol]) {
                    $this->update(array(
                        $hashIdCol => dechex((double)$result).
                            bin2hex(openssl_random_pseudo_bytes(16))
                    ));
                }
            }
            
            # Kerro uusi id mahdollisille alitietuille
            if (isset($this->_foreignIdCol)) {
                $fk = $this->_foreignIdCol;
                foreach ($this->_childs as $key => $childs) {
                    if (!is_array($childs)) {
                        continue;
                    }
                    
                    foreach ($childs as $child) {
                        if (is_subclass_of($child,"Model")) {
                            $child->{$fk} = $result;
                        }
                    }
                }
            }
             
            $this->writeChilds();
            
            return $result;
        }
        
        /**
         * Funktio kirjoittaa alitietuiden datan tietokantaan.
         */
        public function writeChilds() {
            $this->_callChilds("write");
        }
    }